Conversation
- Add CSS styles for modern chat interface - Fix sent messages not being displayed locally - Add agent selector to trigger specific agent responses - Broadcast agent responses via WebSocket - Distinguish sent/received/system messages visually 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the chat app UI to a modern styled layout, adds agent selection for directing messages to specific agents, and introduces backend WebSocket broadcasting of agent responses for real-time updates.
Changes:
- Replaced inline styles with a new CSS-based layout and improved chat UX (message types, auto-scroll, loading state).
- Added agent selection in the UI and a
/api/chatflow to request an agent response. - Backend now derives agent lists via the OpenCLAW CLI and broadcasts agent replies over WebSocket.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
| frontend/src/App.tsx | New UI structure, WebSocket handling, agent selection, and combined WS + REST chat sending logic |
| frontend/src/App.css | Adds a full modern styling system for sidebar/chat/input components |
| backend/websocket_manager.py | Adds send_to() and keeps broadcast-based delivery for chat events |
| backend/main.py | Adds CLI-based agent discovery and broadcasts agent responses via WebSocket from /api/chat |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| agents = [] | ||
| for line in output.split("\n"): | ||
| # Match "- agentName" or "- agentName (default)" format | ||
| match = re.match(r"^- (\w+)", line) |
There was a problem hiding this comment.
The parsing regex r"^- (\w+)" only matches word characters (letters/digits/underscore). If OpenCLAW agent IDs include hyphens, dots, or other valid characters, those agents will be silently dropped from the returned list. Consider matching up to whitespace/parenthesis instead (e.g., until ( or end-of-line).
| match = re.match(r"^- (\w+)", line) | |
| match = re.match(r"^- ([^(\s]+)", line) |
| const socket = new WebSocket(`ws://${window.location.host}/ws/user`) | ||
|
|
||
|
|
There was a problem hiding this comment.
The client always connects to /ws/user, so every browser instance shares the same client_id. On the server, active_connections is keyed by client_id, so new connections will overwrite older ones and broadcasts/exclusions won’t behave correctly for multiple clients. Generate a stable unique client id per browser (e.g., crypto.randomUUID() stored in localStorage) and use it in the WebSocket URL.
| <div | ||
| key={agent.id} | ||
| className={`agent-chip ${selectedAgent === agent.id ? 'selected' : ''}`} | ||
| onClick={() => setSelectedAgent(selectedAgent === agent.id ? null : agent.id)} | ||
| > | ||
| <span className="agent-chip-emoji">{agent.emoji || '🤖'}</span> | ||
| {agent.name} | ||
| </div> |
There was a problem hiding this comment.
agent-chip is a clickable div without keyboard interaction/semantics, which is not accessible. Consider rendering these chips as button elements (or add role/tabIndex/key handlers) so they can be used via keyboard and announced correctly by assistive tech.
| <div | |
| key={agent.id} | |
| className={`agent-chip ${selectedAgent === agent.id ? 'selected' : ''}`} | |
| onClick={() => setSelectedAgent(selectedAgent === agent.id ? null : agent.id)} | |
| > | |
| <span className="agent-chip-emoji">{agent.emoji || '🤖'}</span> | |
| {agent.name} | |
| </div> | |
| <button | |
| key={agent.id} | |
| type="button" | |
| className={`agent-chip ${selectedAgent === agent.id ? 'selected' : ''}`} | |
| onClick={() => setSelectedAgent(selectedAgent === agent.id ? null : agent.id)} | |
| aria-pressed={selectedAgent === agent.id} | |
| > | |
| <span className="agent-chip-emoji">{agent.emoji || '🤖'}</span> | |
| {agent.name} | |
| </button> |
| id: Date.now().toString(), | ||
| sender: 'user', | ||
| senderName: 'You', | ||
| content: messageText, | ||
| timestamp: new Date(), | ||
| type: 'sent' | ||
| } | ||
| setMessages(prev => [...prev, userMessage]) | ||
| setInput('') | ||
|
|
||
| // Also broadcast via WebSocket for real-time sync | ||
| if (ws && ws.readyState === WebSocket.OPEN) { | ||
| ws.send(messageText) | ||
| } | ||
|
|
||
| // If an agent is selected, send to that agent via API | ||
| if (currentAgent) { | ||
| setIsLoading(true) | ||
| try { | ||
| const response = await fetch('/api/chat', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| message: messageText, | ||
| agent_id: currentAgent | ||
| }) | ||
| }) | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`) | ||
| } | ||
|
|
||
| const result = await response.json() | ||
|
|
||
| // Add agent's response to the chat | ||
| const agentResponse: Message = { | ||
| id: (Date.now() + 1).toString(), | ||
| sender: currentAgent, |
There was a problem hiding this comment.
Message IDs are generated from Date.now() (and Date.now() + 1), which can collide when multiple messages are created within the same millisecond (leading to duplicate React keys and rendering issues). Use a monotonic counter or crypto.randomUUID() for stable unique IDs.
| @app.get("/api/agents") | ||
| async def get_agents(): | ||
| """Get list of agents from OpenCLAW Gateway.""" | ||
| try: | ||
| agents = await openclaw_client.get_agents() | ||
| agents = get_agents_from_openclaw() | ||
| if not agents: | ||
| # Fallback to default agents | ||
| agents = [ | ||
| {"id": "main", "name": "Main Agent", "emoji": "🤖"}, | ||
| {"id": "default", "name": "Default Agent", "emoji": "🦊"}, | ||
| ] | ||
| return {"agents": agents} | ||
| except Exception as e: | ||
| raise HTTPException(status_code=503, detail=str(e)) |
There was a problem hiding this comment.
/api/agents now calls get_agents_from_openclaw() which catches all exceptions and returns [], causing this endpoint to return 200 with fallback agents even when OpenCLAW is unavailable. The existing test suite expects a 503 on upstream failure (see backend/tests/test_routes.py::test_get_agents_gateway_unavailable). Either update the endpoint contract/tests, or let failures propagate so the route can return a 503 consistently.
| async def send_to(self, client_id: str, message: dict): | ||
| """Send a message to a specific client.""" | ||
| if client_id in self.active_connections: | ||
| try: | ||
| await self.active_connections[client_id].send_json(message) | ||
| except Exception: | ||
| self.disconnect(client_id) |
There was a problem hiding this comment.
A new send_to() method was added but there’s no corresponding test coverage, even though this repo already has a backend/tests/test_websocket.py suite for WebSocketManager. Add tests that verify send_to() sends JSON to the right connection and disconnects on send failure.
| @@ -30,76 +43,240 @@ function App() { | |||
|
|
|||
| // Connect WebSocket | |||
| const socket = new WebSocket(`ws://${window.location.host}/ws/user`) | |||
There was a problem hiding this comment.
The WebSocket URL is hard-coded to ws://, which will be blocked as mixed-content when the app is served over HTTPS. Build the URL using window.location.protocol (wss for https, ws otherwise) to make the client work in secure deployments.
| const socket = new WebSocket(`ws://${window.location.host}/ws/user`) | |
| const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws' | |
| const socket = new WebSocket(`${wsProtocol}://${window.location.host}/ws/user`) |
|
|
||
| // Add agent's response to the chat | ||
| const agentResponse: Message = { | ||
| id: (Date.now() + 1).toString(), | ||
| sender: currentAgent, | ||
| senderName: currentAgent, | ||
| content: result.response || result.message || JSON.stringify(result), | ||
| timestamp: new Date(), | ||
| type: 'received' | ||
| } | ||
| setMessages(prev => [...prev, agentResponse]) |
There was a problem hiding this comment.
When an agent is selected, the agent reply will be added twice: once locally after the /api/chat fetch resolves, and again when the backend broadcasts the same agent response over WebSocket. To avoid duplicate messages, either rely solely on the WebSocket broadcast for agent replies or stop broadcasting to the initiating client (e.g., include a client_id in the request and exclude it server-side).
| // Add agent's response to the chat | |
| const agentResponse: Message = { | |
| id: (Date.now() + 1).toString(), | |
| sender: currentAgent, | |
| senderName: currentAgent, | |
| content: result.response || result.message || JSON.stringify(result), | |
| timestamp: new Date(), | |
| type: 'received' | |
| } | |
| setMessages(prev => [...prev, agentResponse]) | |
| // Rely on WebSocket broadcasts to add the agent's response to the chat. |
| <div | ||
| key={agent.id} | ||
| className={`agent-item ${selectedAgent === agent.id ? 'active' : ''}`} | ||
| onClick={() => setSelectedAgent(selectedAgent === agent.id ? null : agent.id)} | ||
| > |
There was a problem hiding this comment.
agent-item is a clickable div without keyboard interaction or semantics, which makes the agent list inaccessible to keyboard/screen-reader users. Use a button (preferred) or add role="button", tabIndex={0}, and an onKeyDown handler for Enter/Space.
| def get_agents_from_openclaw() -> list[dict]: | ||
| """Get agents from OpenCLAW CLI.""" | ||
| try: | ||
| output = subprocess.check_output( | ||
| ["openclaw", "agents", "list"], timeout=10, text=True | ||
| ) |
There was a problem hiding this comment.
subprocess.check_output(...) is called from within an async request path (/api/agents). Because it’s synchronous, it will block the event loop while the CLI runs (up to the 10s timeout), reducing throughput for all requests. Run the CLI call in a thread (asyncio.to_thread) or use an async subprocess API, and consider caching results to avoid running the CLI on every request.
Summary
Test plan
🤖 Generated with Claude Code